jest.dontMock('../arrayContainsObject');
jest.dontMock('../canBeSerialized');
jest.dontMock('../CoreManager');
jest.dontMock('../promiseUtils');
jest.dontMock('../decode');
jest.dontMock('../encode');
jest.dontMock('../equals');
jest.dontMock('../escape');
jest.dontMock('../ObjectStateMutations');
jest.dontMock('../parseDate');
jest.dontMock('../ParseError');
jest.dontMock('../ParseFile');
jest.dontMock('../ParseGeoPoint');
jest.dontMock('../ParseObject');
jest.dontMock('../ParseOp');
jest.dontMock('../ParsePolygon');
jest.dontMock('../ParseRelation');
jest.dontMock('../RESTController');
jest.dontMock('../SingleInstanceStateController');
jest.dontMock('../TaskQueue');
jest.dontMock('../unique');
jest.dontMock('../UniqueInstanceStateController');
jest.dontMock('../unsavedChildren');
jest.dontMock('../ParseACL');
jest.dontMock('../LocalDatastore');

jest.mock('../uuid', () => {
  let value = 0;
  return () => value++;
});
jest.dontMock('./test_helpers/mockFetch');
jest.dontMock('./test_helpers/flushPromises');

jest.useFakeTimers();

const mockRelation = function (parent, key) {
  // The parent and key fields will be populated by the parent
  if (parent) {
    this.parentClass = parent.className;
    this.parentId = parent.id;
  }
  this.key = key;
};
mockRelation.prototype.add = function (obj) {
  this.targetClassName = obj.className;
};
mockRelation.prototype.toJSON = function () {
  return {
    __type: 'Relation',
    className: this.targetClassName,
  };
};
mockRelation.prototype._ensureParentAndKey = function (parent, key) {
  this.key = this.key || key;
  if (this.key !== key) {
    throw new Error('Internal Error. Relation retrieved from two different keys.');
  }
  if (this.parent) {
    if (this.parent.className !== parent.className) {
      throw new Error('Internal Error. Relation retrieved from two different Objects.');
    }
    if (this.parent.id) {
      if (this.parent.id !== parent.id) {
        throw new Error('Internal Error. Relation retrieved from two different Objects.');
      }
    } else if (parent.id) {
      this.parent = parent;
    }
  } else {
    this.parent = parent;
  }
};
jest.setMock('../ParseRelation', mockRelation);

const mockQuery = function (className) {
  this.className = className;
};
mockQuery.prototype.containedIn = function (_field, ids) {
  this.results = [];
  ids.forEach(id => {
    this.results.push(
      ParseObject.fromJSON({
        className: this.className,
        objectId: id,
      })
    );
  });
};

mockQuery.prototype.include = function (keys) {
  this._include = keys;
};

mockQuery.prototype.find = function () {
  return Promise.resolve(this.results);
};
mockQuery.prototype.get = function (id) {
  const object = ParseObject.fromJSON({
    className: this.className,
    objectId: id,
  });
  return Promise.resolve(object);
};
jest.setMock('../ParseQuery', mockQuery);

import { DEFAULT_PIN, PIN_PREFIX } from '../LocalDatastoreUtils';

const mockLocalDatastore = {
  isEnabled: false,
  fromPinWithName: jest.fn(),
  pinWithName: jest.fn(),
  unPinWithName: jest.fn(),
  _handlePinAllWithName: jest.fn(),
  _handleUnPinAllWithName: jest.fn(),
  _getAllContent: jest.fn(),
  _serializeObjectsFromPinName: jest.fn(),
  _serializeObject: jest.fn(),
  _transverseSerializeObject: jest.fn(),
  _destroyObjectIfPinned: jest.fn(),
  _updateLocalIdForObject: jest.fn((_localId, /** @type {ParseObject}*/ object) => {
    if (!mockLocalDatastore.isEnabled) {
      return;
    }
    /* eslint-disable @typescript-eslint/no-unused-vars */
    // (Taken from LocalDataStore source) This fails for nested objects that are not ParseObject
    const objectKey = mockLocalDatastore.getKeyForObject(object);
  }),
  _updateObjectIfPinned: jest.fn(),
  getKeyForObject: jest.fn(object => {
    // (Taken from LocalDataStore source) This fails for nested objects that are not ParseObject
    const objectId = object.objectId || object._getId();
    const OBJECT_PREFIX = 'Parse_LDS_';
    return `${OBJECT_PREFIX}${object.className}_${objectId}`;
  }),
  updateFromServer: jest.fn(),
  _clear: jest.fn(),
  checkIfEnabled: jest.fn(() => {
    if (!mockLocalDatastore.isEnabled) {
      console.error('Parse.enableLocalDatastore() must be called first');
    }
    return mockLocalDatastore.isEnabled;
  }),
};
jest.setMock('../LocalDatastore', mockLocalDatastore);

const CoreManager = require('../CoreManager').default;
const EventuallyQueue = require('../EventuallyQueue').default;
const ParseACL = require('../ParseACL').default;
const ParseError = require('../ParseError').default;
const ParseFile = require('../ParseFile').default;
const ParseGeoPoint = require('../ParseGeoPoint').default;
const ParsePolygon = require('../ParsePolygon').default;
const ParseObject = require('../ParseObject').default;
const ParseOp = require('../ParseOp');
const RESTController = require('../RESTController').default;
const SingleInstanceStateController = require('../SingleInstanceStateController');
const unsavedChildren = require('../unsavedChildren').default;

const mockFetch = require('./test_helpers/mockFetch');
const flushPromises = require('./test_helpers/flushPromises');

CoreManager.setLocalDatastore(mockLocalDatastore);
CoreManager.setRESTController(RESTController);
CoreManager.setEventuallyQueue(EventuallyQueue);
CoreManager.setInstallationController({
  currentInstallationId() {
    return Promise.resolve('iid');
  },
  currentInstallation() { },
  updateInstallationOnDisk() { },
});
CoreManager.set('APPLICATION_ID', 'A');
CoreManager.set('JAVASCRIPT_KEY', 'B');
CoreManager.set('MASTER_KEY', 'C');
CoreManager.set('VERSION', 'V');
// Register our mocks
jest.spyOn(CoreManager, 'getParseQuery').mockImplementation(() => mockQuery);
jest.spyOn(CoreManager, 'getEventuallyQueue').mockImplementation(() => EventuallyQueue);
jest.spyOn(CoreManager, 'getParseUser').mockImplementation(() => require('../ParseUser').default);

const { SetOp, UnsetOp, IncrementOp } = require('../ParseOp');

describe('ParseObject', () => {
  beforeEach(() => {
    ParseObject.enableSingleInstance();
    jest.clearAllMocks();
  });

  it('is initially created with no Id', () => {
    const o = new ParseObject('Item');
    expect(o.id).toBe(undefined);
    expect(o._localId).toBe(undefined);
    expect(o.dirty()).toBe(true);
  });

  it('can be created with initial attributes', () => {
    const o = new ParseObject({
      className: 'Item',
      value: 12,
    });
    expect(o.className).toBe('Item');
    expect(o.attributes).toEqual({ value: 12 });
  });

  it('can be created with attributes parameter', () => {
    const o = new ParseObject('Item', {
      value: 12,
    });
    expect(o.className).toBe('Item');
    expect(o.attributes).toEqual({ value: 12 });
  });

  it('can ignore setting invalid key', () => {
    const o = new ParseObject('Item');
    const o2 = o.set(1234);
    expect(o).toEqual(o2);
  });

  it('can ignore setting createdAt', () => {
    const o = new ParseObject('Item');
    o.set('createdAt', '1234');
    expect(o.get('createdAt')).toEqual(undefined);
  });

  it('can handle setting relationOp', () => {
    const child = new ParseObject('Child');
    child.id = 'child1234';
    const relationOpJSON = { __op: 'AddRelation', objects: [child] };
    const o = new ParseObject('Item');
    o.set('friends', relationOpJSON);
    o._handleSaveResponse({});
    expect(o.get('friends').targetClassName).toBe('Child');
  });

  it('cannot create with invalid attributes', () => {
    expect(() => {
      new ParseObject({
        className: 'Item',
        'invalid#name': 'foo',
      });
    }).toThrow("Can't create an invalid Parse Object");
  });

  it('can ignore validation if ignoreValidation option is provided', () => {
    class ValidatedObject extends ParseObject {
      validate(attrs) {
        if (Object.hasOwn(attrs, 'badAttr')) {
          return 'you have the bad attr';
        }
      }
    }

    const o = new ValidatedObject(
      {
        className: 'Item',
        value: 12,
        badAttr: true,
      },
      { ignoreValidation: true }
    );

    expect(o.attributes.value).toBe(12);
    expect(o.attributes.badAttr).toBe(true);
  });

  it('can be inflated from server JSON', () => {
    const date = new Date();
    const json = {
      className: 'Item',
      createdAt: '2013-12-14T04:51:19Z',
      objectId: 'I1',
      size: 'medium',
      date: date,
    };
    const o = ParseObject.fromJSON(json);
    expect(o.className).toBe('Item');
    expect(o.id).toBe('I1');
    expect(o.attributes).toEqual({
      size: 'medium',
      createdAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
      updatedAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
      date,
    });
    expect(o.dirty()).toBe(false);
    expect(o.get('date')).toBeInstanceOf(Date);
  });

  it('can be dirty with fromJSON', () => {
    const date = new Date();
    const json = {
      className: 'Item',
      createdAt: '2013-12-14T04:51:19Z',
      objectId: 'I1',
      size: 'medium',
      date: date,
    };
    const o = ParseObject.fromJSON(json, false, true);
    expect(o.className).toBe('Item');
    expect(o.id).toBe('I1');
    expect(o.attributes).toEqual({
      size: 'medium',
      createdAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
      updatedAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
      date,
    });
    expect(o.dirty()).toBe(true);
    expect(o.dirtyKeys()).toEqual(['size', 'date']);
  });

  it('can override old data when inflating from the server', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'I01',
      size: 'small',
    });
    expect(o.get('size')).toBe('small');
    const o2 = ParseObject.fromJSON(
      {
        className: 'Item',
        objectId: 'I01',
        disabled: true,
      },
      true
    );
    expect(o.get('disabled')).toBe(true);
    expect(o.get('size')).toBe(undefined);
    expect(o.has('size')).toBe(false);

    expect(o2.get('disabled')).toBe(true);
    expect(o2.get('size')).toBe(undefined);
    expect(o2.has('size')).toBe(false);
  });

  it('is given a local Id once dirtied', () => {
    const o = new ParseObject('Item');
    o.set('size', 'small');
    expect(o._localId).toBeTruthy();
  });

  it('has a read-only attributes property', () => {
    const o = new ParseObject('Item');
    o.set('size', 'small');
    expect(function () {
      o.attributes.size = 'large';
    }).toThrow();
  });

  it('exposes read-only createdAt and updatedAt', () => {
    const o = new ParseObject('Item');
    expect(o.get('createdAt')).toBe(undefined);
    expect(o.get('updatedAt')).toBe(undefined);
    const created = new Date();
    const updated = new Date();
    o._finishFetch({
      objectId: 'O1',
      createdAt: { __type: 'Date', iso: created.toISOString() },
      updatedAt: { __type: 'Date', iso: updated.toISOString() },
    });
    expect(o.get('createdAt')).toEqual(created);
    expect(o.get('updatedAt')).toEqual(updated);
    expect(o.createdAt).toEqual(created);
    expect(o.updatedAt).toEqual(updated);
  });

  it('fetch ACL from serverData', () => {
    const ACL = new ParseACL({ user1: { read: true } });
    const o = new ParseObject('Item');
    o._finishFetch({
      objectId: 'O1',
      ACL: { user1: { read: true } },
    });
    expect(o.getACL()).toEqual(ACL);
  });

  it('encodes ACL from json', () => {
    const ACL = new ParseACL({ user1: { read: true } });
    const o = new ParseObject('Item');
    o.set({ ACL: ACL.toJSON() });
    expect(o.getACL()).toEqual(ACL);
  });

  it('can be rendered to JSON', () => {
    let o = new ParseObject('Item');
    o.set({
      size: 'large',
      inStock: 18,
    });
    expect(o.toJSON()).toEqual({
      size: 'large',
      inStock: 18,
    });
    o = new ParseObject('Item');
    o._finishFetch({
      objectId: 'O2',
      size: 'medium',
      inStock: 12,
    });
    expect(o.id).toBe('O2');
    expect(o.toJSON()).toEqual({
      objectId: 'O2',
      size: 'medium',
      inStock: 12,
    });
  });

  it('encodes createdAt and updatedAt fields as strings', () => {
    const o = ParseObject.fromJSON({
      id: 'hasDates',
      className: 'Item',
      createdAt: {
        __type: 'Date',
        iso: new Date(Date.UTC(2015, 0, 1)).toJSON(),
      },
      updatedAt: {
        __type: 'Date',
        iso: new Date(Date.UTC(2015, 0, 1)).toJSON(),
      },
      foo: 'bar',
    });
    expect(o.toJSON()).toEqual({
      id: 'hasDates',
      createdAt: '2015-01-01T00:00:00.000Z',
      updatedAt: '2015-01-01T00:00:00.000Z',
      foo: 'bar',
    });
  });

  it('can convert to a pointer', () => {
    const o = new ParseObject('Item');
    expect(function () {
      o.toPointer();
    }).toThrow('Cannot create a pointer to an unsaved ParseObject');
    o.id = 'anObjectId';
    expect(o.toPointer()).toEqual({
      __type: 'Pointer',
      className: 'Item',
      objectId: 'anObjectId',
    });
  });

  it('can convert to a offline pointer', () => {
    const o = new ParseObject('Item');
    o.id = 'AnObjectId';
    expect(function () {
      o.toOfflinePointer();
    }).toThrow('Cannot create a offline pointer to a saved ParseObject');
    o._localId = 'local1234';
    expect(o.toOfflinePointer()).toEqual({
      __type: 'Object',
      className: 'Item',
      _localId: 'local1234',
    });
  });

  it('can test equality against another ParseObject', () => {
    const a = new ParseObject('Item');
    expect(a.equals(a)).toBe(true);
    const b = new ParseObject('Item');
    expect(a.equals(b)).toBe(false);
    expect(b.equals(a)).toBe(false);
    a.id = 'anObjectId';
    b.id = 'anObjectId';
    expect(a.equals(b)).toBe(true);
    expect(b.equals(a)).toBe(true);
  });

  it('can set a field', () => {
    const o = new ParseObject('Person');
    expect(o.attributes).toEqual({});
    o.set('name', 'Will');
    expect(o.attributes).toEqual({ name: 'Will' });
    expect(o.op('name') instanceof SetOp).toBe(true);
    expect(o.dirtyKeys()).toEqual(['name']);
    expect(o.dirty()).toBe(true);
    expect(o.dirty('name')).toBe(true);
    expect(o._getSaveJSON()).toEqual({ name: 'Will' });

    // set multiple fields at once
    o.set({ name: 'William', behavior: 'formal' });
    expect(o.attributes).toEqual({ name: 'William', behavior: 'formal' });
  });

  it('can set id with the objectId attribute', () => {
    const o = new ParseObject('Person');
    expect(o.attributes).toEqual({});
    expect(o.id).toBe(undefined);
    o.set({ objectId: 'oid' });
    expect(o.attributes).toEqual({});
    expect(o.id).toBe('oid');
  });

  it('can get an escaped version of a field', () => {
    const o = new ParseObject('Person');
    o.set('age', 28);
    o.set('phoneProvider', 'AT&T');
    o.set('objectField', { toString: 'hacking' });
    expect(o.escape('notSet')).toBe('');
    expect(o.escape('age')).toBe('28');
    expect(o.escape('phoneProvider')).toBe('AT&amp;T');
    expect(o.escape('objectField')).toBe('');
  });

  it('can tell if it has an attribute', () => {
    const o = new ParseObject('Person');
    o.set('age', 28);
    expect(o.has('name')).toBe(false);
    expect(o.has('age')).toBe(true);
  });

  it('can tell if a field is dirty', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'p99',
      age: 28,
      human: true,
      objectField: { foo: 'bar' },
    });
    expect(o.dirty()).toBe(false);
    expect(o.dirty('age')).toBe(false);
    expect(o.dirty('human')).toBe(false);
    expect(o.dirty('unset')).toBe(false);
    expect(o.dirty('objectField')).toBe(false);
    o.set('human', false);
    o.set('objectField', { foo: 'baz' });
    expect(o.dirty()).toBe(true);
    expect(o.dirty('age')).toBe(false);
    expect(o.dirty('human')).toBe(true);
    expect(o.dirty('unset')).toBe(false);
    expect(o.dirty('objectField')).toBe(true);
  });

  it('can unset a field', () => {
    const o = new ParseObject('Person');
    o.id = 'anObjectId';
    o.set('name', 'Will');
    expect(o.attributes).toEqual({ name: 'Will' });
    o.unset('name');
    expect(o.attributes).toEqual({});
    // Even when you unset an unsaved set, it's still dirty
    expect(o.op('name') instanceof UnsetOp).toBe(true);
    expect(o.dirty()).toBe(true);
    expect(o.dirtyKeys()).toEqual(['name']);

    const o2 = new ParseObject('Person');
    o2._finishFetch({
      objectId: 'P1',
      name: 'Will',
    });
    expect(o2.attributes).toEqual({ name: 'Will' });
    o2.unset('name');
    expect(o2.attributes).toEqual({});
  });

  it('can clear all fields', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'P95',
      createdAt: { __type: 'Date', iso: new Date().toISOString() },
      updatedAt: { __type: 'Date', iso: new Date().toISOString() },
    });
    o.set({ a: 'a', b: 'b', c: 'c' });
    expect(o.dirty('a')).toBe(true);
    expect(o.dirty('b')).toBe(true);
    expect(o.dirty('c')).toBe(true);
    o.clear();
    expect(o.get('a')).toBe(undefined);
    expect(o.get('b')).toBe(undefined);
    expect(o.get('c')).toBe(undefined);
  });

  it('can increment a field', () => {
    const o = new ParseObject('Person');
    o.increment('age');
    expect(o.attributes).toEqual({ age: 1 });
    expect(o.op('age') instanceof IncrementOp).toBe(true);
    expect(o.dirtyKeys()).toEqual(['age']);
    expect(o._getSaveJSON()).toEqual({
      age: { __op: 'Increment', amount: 1 },
    });

    o.increment('age', 4);
    expect(o.attributes).toEqual({ age: 5 });
    expect(o._getSaveJSON()).toEqual({
      age: { __op: 'Increment', amount: 5 },
    });

    expect(o.increment.bind(o, 'age', 'four')).toThrow('Cannot increment by a non-numeric amount.');
    expect(o.increment.bind(o, 'age', null)).toThrow('Cannot increment by a non-numeric amount.');
    expect(o.increment.bind(o, 'age', { amount: 4 })).toThrow(
      'Cannot increment by a non-numeric amount.'
    );

    o.set('age', 30);
    o.increment('age');
    expect(o.attributes).toEqual({ age: 31 });
    expect(o._getSaveJSON()).toEqual({
      age: 31,
    });

    const o2 = new ParseObject('Person');
    o2._finishFetch({
      objectId: 'P2',
      age: 40,
    });
    expect(o2.attributes).toEqual({ age: 40 });
    o2.increment('age');
    expect(o2.attributes).toEqual({ age: 41 });
  });

  it('can decrement a field', () => {
    const o = new ParseObject('Person');
    o.decrement('age');
    expect(o.attributes).toEqual({ age: -1 });
    expect(o.op('age') instanceof IncrementOp).toBe(true);
    expect(o.dirtyKeys()).toEqual(['age']);
    expect(o._getSaveJSON()).toEqual({
      age: { __op: 'Increment', amount: -1 },
    });

    o.decrement('age', 4);
    expect(o.attributes).toEqual({ age: -5 });
    expect(o._getSaveJSON()).toEqual({
      age: { __op: 'Increment', amount: -5 },
    });

    expect(o.decrement.bind(o, 'age', 'four')).toThrow('Cannot decrement by a non-numeric amount.');
    expect(o.decrement.bind(o, 'age', null)).toThrow('Cannot decrement by a non-numeric amount.');
    expect(o.decrement.bind(o, 'age', { amount: 4 })).toThrow(
      'Cannot decrement by a non-numeric amount.'
    );

    o.set('age', 30);
    o.decrement('age');
    expect(o.attributes).toEqual({ age: 29 });
    expect(o._getSaveJSON()).toEqual({
      age: 29,
    });

    const o2 = new ParseObject('Person');
    o2._finishFetch({
      objectId: 'ABC123',
      age: 40,
    });
    expect(o2.attributes).toEqual({ age: 40 });
    o2.decrement('age');
    expect(o2.attributes).toEqual({ age: 39 });
  });

  it('can set nested field', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'setNested',
      objectField: {
        number: 5,
        letter: 'a',
      },
      otherField: {},
    });

    expect(o.attributes).toEqual({
      objectField: { number: 5, letter: 'a' },
      otherField: {},
    });
    o.set('otherField', { hello: 'world' });
    o.set('objectField.number', 20);

    expect(o.attributes).toEqual({
      objectField: { number: 20, letter: 'a' },
      otherField: { hello: 'world' },
    });
    expect(o.op('objectField.number') instanceof SetOp).toBe(true);
    expect(o.dirtyKeys()).toEqual(['otherField', 'objectField.number', 'objectField']);
    expect(o._getSaveJSON()).toEqual({
      'objectField.number': 20,
      otherField: { hello: 'world' },
    });
    expect(o.toJSON()).toEqual({
      objectField: {
        number: 20,
        letter: 'a',
      },
      otherField: { hello: 'world' },
      objectId: 'setNested',
    });
  });

  it('can set multiple nested fields (regression test for #1450)', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'setNested2_1450',
      objectField: {
        number: 5,
        letter: 'a',
        nested: {
          number: 0,
          letter: 'b',
        },
      },
    });

    expect(o.attributes).toEqual({
      objectField: { number: 5, letter: 'a', nested: { number: 0, letter: 'b' } },
    });
    o.set('objectField.number', 20);
    o.set('objectField.letter', 'b');
    o.set('objectField.nested.number', 1);
    o.set('objectField.nested.letter', 'c');

    expect(o.attributes).toEqual({
      objectField: { number: 20, letter: 'b', nested: { number: 1, letter: 'c' } },
    });
    expect(o.op('objectField.number') instanceof SetOp).toBe(true);
    expect(o.dirtyKeys()).toEqual([
      'objectField.number',
      'objectField.letter',
      'objectField.nested.number',
      'objectField.nested.letter',
      'objectField',
    ]);
    expect(o._getSaveJSON()).toEqual({
      'objectField.number': 20,
      'objectField.letter': 'b',
      'objectField.nested.number': 1,
      'objectField.nested.letter': 'c',
    });

    o.revert('objectField.nested.number');
    o.revert('objectField.nested.letter');
    expect(o._getSaveJSON()).toEqual({
      'objectField.number': 20,
      'objectField.letter': 'b',
    });
    expect(o.attributes).toEqual({
      objectField: { number: 20, letter: 'b', nested: { number: 0, letter: 'b' } },
    });

    // Also test setting new root fields using the dot notation
    o.set('objectField2.number', 0);
    expect(o._getSaveJSON()).toEqual({
      'objectField.number': 20,
      'objectField.letter': 'b',
      'objectField2.number': 0,
    });
    expect(o.attributes).toEqual({
      objectField: { number: 20, letter: 'b', nested: { number: 0, letter: 'b' } },
      objectField2: { number: 0 },
    });
  });

  it('can increment a nested field', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'incNested',
      objectField: {
        number: 5,
        letter: 'a',
      },
    });

    expect(o.attributes).toEqual({
      objectField: { number: 5, letter: 'a' },
    });
    o.increment('objectField.number');

    expect(o.attributes).toEqual({
      objectField: { number: 6, letter: 'a' },
    });
    expect(o.op('objectField.number') instanceof IncrementOp).toBe(true);
    expect(o.dirtyKeys()).toEqual(['objectField.number', 'objectField']);
    expect(o._getSaveJSON()).toEqual({
      'objectField.number': {
        __op: 'Increment',
        amount: 1,
      },
    });

    // Nested objects only return values changed
    o._handleSaveResponse({
      objectId: 'incNested',
      objectField: {
        number: 6,
      },
    });
    expect(o.get('objectField').number).toEqual(6);
    expect(o.get('objectField').letter).toEqual('a');
  });

  it('can add elements to an array field', () => {
    const o = new ParseObject('Schedule');
    o.add('available', 'Monday');
    o.add('available', 'Wednesday');
    expect(o.get('available')).toEqual(['Monday', 'Wednesday']);

    o.set('colors', ['red', 'green']);
    o.add('colors', 'blue');
    expect(o.get('colors')).toEqual(['red', 'green', 'blue']);

    o._handleSaveResponse({
      objectId: 'S1',
      available: ['Monday', 'Wednesday'],
      colors: ['red', 'green', 'blue'],
    });

    o.addUnique('available', 'Thursday');
    o.addUnique('available', 'Monday');
    expect(o.get('available')).toEqual(['Monday', 'Wednesday', 'Thursday']);
  });

  it('can add elements to an array field in batch mode', () => {
    const o = new ParseObject('Schedule');
    o.addAll('available', ['Monday', 'Wednesday']);
    expect(o.get('available')).toEqual(['Monday', 'Wednesday']);

    o.set('colors', ['red']);
    o.addAll('colors', ['green', 'blue']);
    expect(o.get('colors')).toEqual(['red', 'green', 'blue']);

    o._handleSaveResponse({
      objectId: 'S1',
      available: ['Monday', 'Wednesday'],
      colors: ['red', 'green', 'blue'],
    });

    o.addAllUnique('available', ['Thursday', 'Monday']);
    expect(o.get('available').length).toEqual(3);
  });

  it('can remove elements from an array field', () => {
    const o = new ParseObject('Schedule');
    o.set('available', ['Monday', 'Tuesday']);
    o.remove('available', 'Tuesday');
    o.remove('available', 'Saturday');
    expect(o.get('available')).toEqual(['Monday']);

    o._handleSaveResponse({
      objectId: 'S2',
      available: ['Monday'],
    });

    o.remove('available', 'Monday');
    o.remove('available', 'Tuesday');
    expect(o.get('available')).toEqual([]);
  });

  it('can remove elements from an array field in batch mode', () => {
    const o = new ParseObject('Schedule');
    o.set('available', ['Monday', 'Tuesday']);
    o.removeAll('available', ['Tuesday', 'Saturday']);
    expect(o.get('available')).toEqual(['Monday']);

    o._handleSaveResponse({
      objectId: 'S2',
      available: ['Monday'],
    });

    o.removeAll('available', ['Monday', 'Tuesday']);
    expect(o.get('available')).toEqual([]);
  });

  it('can chain sets', () => {
    const o = new ParseObject('Person');
    o.set('developer', true).set('platform', 'web');
    expect(o.attributes).toEqual({
      developer: true,
      platform: 'web',
    });
  });

  it('can set and retrieve ACLs', () => {
    const acl = new ParseACL();
    const o = new ParseObject('Listing');
    o.setACL(acl);
    expect(o.get('ACL')).toBe(acl);
    expect(o.getACL()).toBe(acl);
  });

  it('can manipulate relations on fields', () => {
    const o = new ParseObject('Person');
    o.id = 'AA';
    o.set('age', 38);
    expect(o.relation.bind(o, 'age')).toThrow('Called relation() on non-relation field age');
    const rel = o.relation('friends');
    expect(rel.parentClass).toBe('Person');
    expect(rel.parentId).toBe('AA');
    expect(rel.key).toBe('friends');
    const friend = new ParseObject('Person');
    friend.id = 'BB';
    rel.add(friend);
    expect(rel.targetClassName).toBe('Person');
  });

  it('can be cloned with relation (#381)', () => {
    const relationJSON = { __type: 'Relation', className: 'Bar' };
    const o = ParseObject.fromJSON({
      objectId: '7777777777',
      className: 'Foo',
      aRelation: relationJSON,
    });
    const o2 = o.clone();
    expect(o2._getSaveJSON().aRelation).toEqual(relationJSON);
  });

  it('can get relation from relation field', () => {
    const relationJSON = { __type: 'Relation', className: 'Bar' };
    const o = ParseObject.fromJSON({
      objectId: '999',
      className: 'Foo',
      aRelation: relationJSON,
    });
    const rel = o.relation('aRelation');
    expect(rel.toJSON()).toEqual(relationJSON);
  });

  it('can detect dirty object children', () => {
    const o = new ParseObject('Person');
    o._finishFetch({
      objectId: 'dirtyObj',
      obj: { a: 12 },
      location: {
        __type: 'GeoPoint',
        latitude: 20,
        longitude: 20,
      },
    });
    expect(o.dirty()).toBe(false);
    o.get('obj').b = 21;
    expect(o.get('obj')).toEqual({
      a: 12,
      b: 21,
    });
    expect(o.dirty()).toBe(true);
    expect(o.dirtyKeys()).toEqual(['obj']);
    expect(o._getSaveJSON()).toEqual({
      obj: {
        a: 12,
        b: 21,
      },
    });
    delete o.get('obj').b;
    expect(o.dirty()).toBe(false);
    expect(o.dirtyKeys()).toEqual([]);
    const loc = o.get('location');
    expect(loc instanceof ParseGeoPoint).toBe(true);
    expect(loc.latitude).toBe(20);
    expect(loc.longitude).toBe(20);
    loc.latitude = 30;
    expect(loc.latitude).toBe(30);
    expect(o.dirty()).toBe(true);
    expect(o.dirtyKeys()).toEqual(['location']);

    const p = new ParseObject('Parent');
    p.set('children', [o]);
    expect(p.dirtyKeys()).toEqual(['children']);
  });

  it('can validate attributes', () => {
    const o = new ParseObject('Listing');
    expect(
      o.validate({
        ACL: 'not an acl',
      })
    ).toEqual(new ParseError(ParseError.OTHER_CAUSE, 'ACL must be a Parse ACL.'));

    expect(
      o.validate({
        'invalid!key': 12,
      })
    ).toEqual(new ParseError(ParseError.INVALID_KEY_NAME, 'Invalid key name: invalid!key'));

    expect(
      o.validate({
        noProblem: 'here',
      })
    ).toBe(false);

    expect(
      o.validate({
        'dot.field': 'here',
      })
    ).toBe(false);
  });

  it('validates attributes on set()', () => {
    const o = new ParseObject('Listing');
    expect(() => {
      o.set('ACL', 'not an acl');
    }).toThrow(new ParseError(ParseError.OTHER_CAUSE, 'ACL must be a Parse ACL.'));
    expect(o.set('ACL', { '*': { read: true, write: false } })).toBe(o);
    expect(() => {
      o.set('$$$', 'o_O');
    }).toThrow(new ParseError(ParseError.INVALID_KEY_NAME, 'Invalid key name: $$$'));
  });

  it('ignores validation if ignoreValidation option is passed to set()', () => {
    const o = new ParseObject('Listing');
    expect(o.set('$$$', 'o_O', { ignoreValidation: true })).toBe(o);
  });

  it('can test object validity', () => {
    // Note: an object should never become invalid through normal use, but
    // it's possible that someone could manipulate it to become invalid
    const o = new ParseObject('Item');
    expect(o.isValid()).toBe(true);
    o.set('someKey', 'someValue');
    expect(o.isValid()).toBe(true);
    o.set('_internalField', 'allow_underscore');
    expect(o.isValid()).toBe(true);
    o._finishFetch({
      objectId: 'O3',
      'invalid!key': 'oops',
    });
    expect(o.isValid()).toBe(false);
  });

  it('shares data among different instances of an object', () => {
    const o = new ParseObject('Person');
    o.id = 'P2';
    const o2 = new ParseObject('Person');
    o2.id = 'P2';
    o.set('age', 22);
    expect(o.get('age')).toBe(22);
    expect(o2.get('age')).toBe(22);
  });

  it('does not stack-overflow when encoding recursive pointers', () => {
    const o = ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'recurParent',
      child: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'recurChild',
      },
    });
    expect(o.toJSON()).toEqual({
      objectId: 'recurParent',
      child: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'recurChild',
      },
    });

    ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'recurChild',
      parent: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'recurParent',
      },
    });

    expect(o.toJSON()).toEqual({
      objectId: 'recurParent',
      child: {
        __type: 'Object',
        className: 'Item',
        objectId: 'recurChild',
        parent: {
          __type: 'Pointer',
          className: 'Item',
          objectId: 'recurParent',
        },
      },
    });
  });

  it('properly encodes createdAt/updatedAt dates on nested objects', () => {
    const o = ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'recurParent',
      createdAt: '1970-01-01T00:00:00.000Z',
      updatedAt: '1970-01-01T00:00:00.000Z',
      aDate: {
        __type: 'Date',
        iso: '1970-01-01T00:00:00.000Z',
      },
      child: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'recurChild',
      },
    });
    expect(o.createdAt.getTime()).toBe(new Date(0).getTime());
    expect(o.updatedAt.getTime()).toBe(new Date(0).getTime());
    expect(o.get('aDate').getTime()).toBe(new Date(0).getTime());

    ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'recurChild',
      createdAt: '1970-01-01T00:00:00.000Z',
      updatedAt: '1970-01-01T00:00:00.000Z',
      parent: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'recurParent',
      },
    });

    expect(o.toJSON()).toEqual({
      objectId: 'recurParent',
      createdAt: '1970-01-01T00:00:00.000Z',
      updatedAt: '1970-01-01T00:00:00.000Z',
      aDate: {
        __type: 'Date',
        iso: '1970-01-01T00:00:00.000Z',
      },
      child: {
        __type: 'Object',
        className: 'Item',
        objectId: 'recurChild',
        createdAt: '1970-01-01T00:00:00.000Z',
        updatedAt: '1970-01-01T00:00:00.000Z',
        parent: {
          __type: 'Pointer',
          className: 'Item',
          objectId: 'recurParent',
        },
      },
    });
  });

  it('encodes multiple layers of nested objects', () => {
    const grandparent = ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'nestedGrand',
      child: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'nestedParent',
      },
    });

    const parent = ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'nestedParent',
      child: {
        __type: 'Pointer',
        className: 'Item',
        objectId: 'nestedChild',
      },
    });

    const child = ParseObject.fromJSON({
      __type: 'Object',
      className: 'Item',
      objectId: 'nestedChild',
      count: 12,
    });

    expect(grandparent.get('child').id).toBe(parent.id);
    expect(grandparent.get('child').get('child').id).toBe(child.id);

    expect(grandparent.toJSON()).toEqual({
      objectId: 'nestedGrand',
      child: {
        __type: 'Object',
        className: 'Item',
        objectId: 'nestedParent',
        child: {
          __type: 'Object',
          className: 'Item',
          objectId: 'nestedChild',
          count: 12,
        },
      },
    });
  });

  it('updates the existed flag when saved', () => {
    const o = new ParseObject('Item');
    expect(o.existed()).toBe(false);
    expect(o.isNew()).toBe(true);
    o._handleSaveResponse(
      {
        objectId: 'I2',
      },
      201
    );
    expect(o.existed()).toBe(false);
    o._handleSaveResponse({}, 200);
    expect(o.existed()).toBe(true);
  });

  it('check existed without object state', () => {
    const o = new ParseObject('Item');
    o.id = 'test890';
    expect(o.existed()).toBe(false);
  });

  it('commits changes to server data when saved', () => {
    const p = new ParseObject('Person');
    p.id = 'P3';
    p.set('age', 24);
    expect(p._getServerData()).toEqual({});
    expect(p.op('age') instanceof SetOp).toBe(true);
    const updated = new Date();
    p._handleSaveResponse({
      updatedAt: { __type: 'Date', iso: updated.toISOString() },
    });
    expect(p._getServerData()).toEqual({
      updatedAt: updated,
      age: 24,
    });
    expect(p.op('age')).toBe(undefined);
  });

  it('handle GeoPoint changes for server', () => {
    const p = new ParseObject('Person');
    p.id = 'PPoint';
    const created = new Date();
    const geopoint = new ParseGeoPoint(0, 0);
    p._handleSaveResponse({
      createdAt: created.toISOString(),
      point: geopoint.toJSON(),
    });
    expect(p._getServerData()).toEqual({
      updatedAt: created,
      createdAt: created,
      point: geopoint,
    });
    expect(p._getServerData().point instanceof ParseGeoPoint).toBe(true);
  });

  it('handle Polygon changes for server', () => {
    const p = new ParseObject('Person');
    p.id = 'PPolygon';
    const created = new Date();
    const polygon = new ParsePolygon([
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
      [0, 0],
    ]);
    p._handleSaveResponse({
      createdAt: created.toISOString(),
      shape: polygon.toJSON(),
    });
    expect(p._getServerData()).toEqual({
      updatedAt: created,
      createdAt: created,
      shape: polygon,
    });
    expect(p._getServerData().shape instanceof ParsePolygon).toBe(true);
  });

  it('handle createdAt string for server', () => {
    const p = new ParseObject('Person');
    p.id = 'P9';
    const created = new Date();
    p._handleSaveResponse({
      createdAt: created.toISOString(),
    });
    expect(p._getServerData()).toEqual({
      updatedAt: created,
      createdAt: created,
    });
  });

  it('isDataAvailable', () => {
    const p = new ParseObject('Person');
    p.id = 'isdataavailable';
    p.set('age', 24);
    expect(p.isDataAvailable()).toBe(false);
    const updated = new Date();
    p._handleSaveResponse({
      updatedAt: { __type: 'Date', iso: updated.toISOString() },
    });
    expect(p.isDataAvailable()).toBe(true);
  });

  it('handles ACL when saved', () => {
    const p = new ParseObject('Person');

    p._handleSaveResponse(
      {
        ACL: {},
      },
      201
    );

    const acl = p.getACL();
    expect(acl).not.toEqual(null);
    expect(acl instanceof ParseACL).toBe(true);
  });

  it('replaces a local id with a real one when saved', () => {
    const p = new ParseObject('Person');
    p.set('age', 34);
    expect(p._localId).toBeTruthy();
    expect(p.id).toBe(undefined);
    const oldState = SingleInstanceStateController.getState({
      className: 'Person',
      id: p._localId,
    });
    p._handleSaveResponse({
      objectId: 'P4',
    });
    expect(p._localId).toBe(undefined);
    expect(p.id).toBe('P4');
    const newState = SingleInstanceStateController.getState({
      className: 'Person',
      id: 'P4',
    });
    expect(oldState.serverData).toBe(newState.serverData);
    expect(oldState.pendingOps).toBe(newState.pendingOps);
    expect(oldState.tasks).toBe(newState.tasks);
  });

  it('marks inflated objects as existed', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'iexist',
      count: 7,
    });
    expect(o.existed()).toBe(true);
  });

  it('can revert unsaved ops', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'canrevert',
      count: 5,
    });
    o.set({ cool: true });
    o.increment('count');
    expect(o.get('cool')).toBe(true);
    expect(o.get('count')).toBe(6);
    o.revert();
    expect(o.get('cool')).toBe(undefined);
    expect(o.op('cool')).toBe(undefined);
    expect(o.get('count')).toBe(5);
    expect(o.op('count')).toBe(undefined);
  });

  it('can revert a specific field in unsaved ops', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'canrevertspecific',
      count: 5,
    });
    o.set({ cool: true });
    o.increment('count');
    expect(o.get('cool')).toBe(true);
    expect(o.get('count')).toBe(6);
    o.revert('cool');
    expect(o.get('cool')).toBe(undefined);
    expect(o.op('cool')).toBe(undefined);
    expect(o.get('count')).toBe(6);
    expect(o.op('count')).not.toBe(undefined);
  });

  it('can revert multiple fields in unsaved ops', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'canrevertmultiple',
      count: 5,
      age: 18,
      gender: 'female',
    });
    o.set({ cool: true, gender: 'male' });
    o.increment('count');
    o.increment('age');
    expect(o.get('cool')).toBe(true);
    expect(o.get('count')).toBe(6);
    expect(o.get('age')).toBe(19);
    expect(o.get('gender')).toBe('male');
    o.revert('age', 'count', 'gender');
    expect(o.get('cool')).toBe(true);
    expect(o.op('cool')).not.toBe(undefined);
    expect(o.get('count')).toBe(5);
    expect(o.op('count')).toBe(undefined);
    expect(o.get('age')).toBe(18);
    expect(o.op('age')).toBe(undefined);
    expect(o.get('gender')).toBe('female');
    expect(o.op('gender')).toBe(undefined);
  });

  it('throws if an array is provided', () => {
    const o = ParseObject.fromJSON({
      className: 'Item',
      objectId: 'throwforarray',
      count: 5,
      age: 18,
      gender: 'female',
    });
    o.set({ cool: true, gender: 'male' });

    const err = 'Parse.Object#revert expects either no, or a list of string, arguments.';

    expect(function () {
      o.revert(['age']);
    }).toThrow(err);

    expect(function () {
      o.revert([]);
    }).toThrow(err);

    expect(function () {
      o.revert('gender', ['age']);
    }).toThrow(err);
  });

  it('can fetchWithInclude', async () => {
    const objectController = CoreManager.getObjectController();
    const spy = jest
      .spyOn(objectController, 'fetch')
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { });

    const parent = new ParseObject('Person');
    await parent.fetchWithInclude('child', {
      useMasterKey: true,
      sessionToken: '123',
    });
    await parent.fetchWithInclude(['child']);
    await parent.fetchWithInclude([['child']]);
    expect(objectController.fetch).toHaveBeenCalledTimes(3);

    expect(objectController.fetch.mock.calls[0]).toEqual([
      parent,
      true,
      { useMasterKey: true, sessionToken: '123', include: ['child'] },
    ]);
    expect(objectController.fetch.mock.calls[1]).toEqual([parent, true, { include: ['child'] }]);
    expect(objectController.fetch.mock.calls[2]).toEqual([parent, true, { include: ['child'] }]);

    spy.mockRestore();
  });

  it('fetchAll with empty values', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    const results = await ParseObject.fetchAll([]);
    expect(results).toEqual([]);
    expect(controller.ajax).toHaveBeenCalledTimes(0);
  });

  it('fetchAll with null', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    const results = await ParseObject.fetchAll(null);
    expect(results).toEqual(undefined);
    expect(controller.ajax).toHaveBeenCalledTimes(0);
  });

  it('fetchAll unique instance', async () => {
    ParseObject.disableSingleInstance();
    const obj = new ParseObject('Item');
    obj.id = 'fetch0';
    const results = await ParseObject.fetchAll([obj]);
    expect(results[0].id).toEqual(obj.id);
  });

  it('fetchAll objects does not exist on server', async () => {
    jest.spyOn(mockQuery.prototype, 'find').mockImplementationOnce(() => {
      return Promise.resolve([]);
    });
    const obj = new ParseObject('Item');
    obj.id = 'fetch-1';
    try {
      await ParseObject.fetchAll([obj]);
      expect(true).toBe(false);
    } catch (e) {
      expect(e.message).toBe('All objects must exist on the server.');
    }
  });

  it('fetchAll unsaved objects', async () => {
    const obj = new ParseObject('Item');
    try {
      await ParseObject.fetchAll([obj]);
      expect(true).toBe(false);
    } catch (e) {
      expect(e.message).toBe('All objects must have an ID');
    }
  });

  it('fetchAll objects with different classes', async () => {
    const obj = new ParseObject('Item');
    const obj2 = new ParseObject('TestObject');
    try {
      await ParseObject.fetchAll([obj, obj2]);
      expect(true).toBe(false);
    } catch (e) {
      expect(e.message).toBe('All objects must have an ID');
    }
  });

  it('fetchAll saved objects with different classes', async () => {
    const obj1 = new ParseObject('Item');
    const obj2 = new ParseObject('TestObject');
    obj1.id = 'fetch1';
    obj2.id = 'fetch2';
    try {
      await ParseObject.fetchAll([obj1, obj2]);
      expect(true).toBe(false);
    } catch (e) {
      expect(e.message).toBe('All objects should be of the same class');
    }
  });

  it('can fetchAllWithInclude', async () => {
    const objectController = CoreManager.getObjectController();
    const spy = jest
      .spyOn(objectController, 'fetch')
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { });

    const parent = new ParseObject('Person');
    await ParseObject.fetchAllWithInclude([parent], 'child', {
      useMasterKey: true,
      sessionToken: '123',
    });
    await ParseObject.fetchAllWithInclude([parent], ['child']);
    await ParseObject.fetchAllWithInclude([parent], [['child']]);
    expect(objectController.fetch).toHaveBeenCalledTimes(3);

    expect(objectController.fetch.mock.calls[0]).toEqual([
      [parent],
      true,
      { useMasterKey: true, sessionToken: '123', include: ['child'] },
    ]);
    expect(objectController.fetch.mock.calls[1]).toEqual([[parent], true, { include: ['child'] }]);
    expect(objectController.fetch.mock.calls[2]).toEqual([[parent], true, { include: ['child'] }]);

    spy.mockRestore();
  });

  it('can fetchAllIfNeededWithInclude', async () => {
    const objectController = CoreManager.getObjectController();
    const spy = jest
      .spyOn(objectController, 'fetch')
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { })
      .mockImplementationOnce(() => { });

    const parent = new ParseObject('Person');
    await ParseObject.fetchAllIfNeededWithInclude([parent], 'child', {
      useMasterKey: true,
      sessionToken: '123',
    });
    await ParseObject.fetchAllIfNeededWithInclude([parent], ['child']);
    await ParseObject.fetchAllIfNeededWithInclude([parent], [['child']]);
    expect(objectController.fetch).toHaveBeenCalledTimes(3);

    expect(objectController.fetch.mock.calls[0]).toEqual([
      [parent],
      false,
      { useMasterKey: true, sessionToken: '123', include: ['child'] },
    ]);
    expect(objectController.fetch.mock.calls[1]).toEqual([[parent], false, { include: ['child'] }]);
    expect(objectController.fetch.mock.calls[2]).toEqual([[parent], false, { include: ['child'] }]);

    spy.mockRestore();
  });

  it('can check if object exists', async () => {
    const parent = new ParseObject('Person');
    expect(await parent.exists()).toBe(false);
    parent.id = '1234';
    expect(await parent.exists()).toBe(true);

    jest.spyOn(mockQuery.prototype, 'get').mockImplementationOnce(() => {
      return Promise.reject({
        code: 101,
      });
    });
    expect(await parent.exists()).toBe(false);

    jest.spyOn(mockQuery.prototype, 'get').mockImplementationOnce(() => {
      return Promise.reject({
        code: 1,
        message: 'Internal Server Error',
      });
    });
    try {
      await parent.exists();
      expect(true).toBe(false);
    } catch (e) {
      expect(e.code).toBe(1);
    }
  });

  it('can save the object', async () => {
    mockFetch([{ status: 200, response: { objectId: 'P5', count: 1 } }]);
    const p = new ParseObject('Person');
    p.set('age', 38);
    p.increment('count');
    const obj = await p.save();
    expect(obj).toBe(p);
    expect(obj.get('age')).toBe(38);
    expect(obj.get('count')).toBe(1);
    expect(obj.op('age')).toBe(undefined);
    expect(obj.dirty()).toBe(false);
  });

  it('can save the object eventually', async () => {
    mockFetch([{ status: 200, response: { objectId: 'PFEventually' } }]);
    const p = new ParseObject('Person');
    p.set('age', 38);
    const obj = await p.saveEventually();
    expect(obj).toBe(p);
    expect(obj.get('age')).toBe(38);
    expect(obj.op('age')).toBe(undefined);
    expect(obj.dirty()).toBe(false);
  });

  it('can save the object eventually on network failure', async () => {
    const p = new ParseObject('Person');
    jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve());
    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { });
    jest.spyOn(p, 'save').mockImplementationOnce(() => {
      throw new ParseError(
        ParseError.CONNECTION_FAILED,
        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
      );
    });
    await p.saveEventually();
    expect(EventuallyQueue.save).toHaveBeenCalledTimes(1);
    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1);
  });

  it('should not save the object eventually on error', async () => {
    const p = new ParseObject('Person');
    jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve());
    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { });
    jest.spyOn(p, 'save').mockImplementationOnce(() => {
      throw new ParseError(ParseError.OTHER_CAUSE, 'Tried to save a batch with a cycle.');
    });
    await p.saveEventually();
    expect(EventuallyQueue.save).toHaveBeenCalledTimes(0);
    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0);
  });

  it('can save the object with key / value', async () => {
    mockFetch([{ status: 200, response: { objectId: 'P8' } }]);
    const p = new ParseObject('Person');
    const obj = await p.save('foo', 'bar');
    expect(obj).toBe(p);
    expect(obj.get('foo')).toBe('bar');
  });

  it('accepts attribute changes on save', (done) => {
    mockFetch([{ status: 200, response: { objectId: 'newattributes' } }]);
    let o = new ParseObject('Item');
    o.save({ key: 'value' })
      .then(() => {
        expect(o.get('key')).toBe('value');

        o = new ParseObject('Item');
        return o.save({ ACL: 'not an acl' });
      })
      .then(null, error => {
        expect(error.code).toBe(-1);
        done();
      });
  });

  it('accepts context on save', async () => {
    mockFetch([{ status: 200, response: { objectId: 'newattributes' } }]);
    // Spy on REST controller
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');
    // Save object
    const context = { a: 'a' };
    const obj = new ParseObject('Item');
    await obj.save(null, { context });
    // Validate
    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._context).toEqual(context);
  });

  it('interpolates delete operations', async () => {
    mockFetch([{ status: 200, response: { objectId: 'newattributes', deletedKey: { __op: 'Delete' } } }]);
    const o = new ParseObject('Item');
    await o.save({ key: 'value', deletedKey: 'keyToDelete' });
    expect(o.get('key')).toBe('value');
    expect(o.get('deletedKey')).toBeUndefined();
  });

  it('can make changes while in the process of a save', async () => {
    mockFetch([{ status: 200, response: { objectId: 'P12', age: 38 } }]);
    const p = new ParseObject('Person');
    p.set('age', 38);
    const result = p.save().then(() => {
      expect(p._getServerData()).toEqual({ age: 38 });
      expect(p._getPendingOps().length).toBe(1);
      expect(p.get('age')).toBe(38);
    });
    expect(p._getPendingOps().length).toBe(1);
    p.increment('age');
    expect(p.get('age')).toBe(39);
    await result;
  });

  it('will queue save operations', async () => {
    mockFetch([
      { status: 200, response: { objectId: 'P15', updates: 1 } },
      { status: 200, response: { objectId: 'P15', updates: 2 } },
    ]);
    const p = new ParseObject('Person');
    expect(p._getPendingOps().length).toBe(1);
    p.increment('updates');
    await p.save();

    expect(p._getPendingOps().length).toBe(1);
    p.increment('updates');
    await p.save();

    expect(p._getPendingOps().length).toBe(1);
    expect(p._getServerData()).toEqual({ updates: 2 });
    expect(p.get('updates')).toBe(2);
    expect(p._getPendingOps().length).toBe(1);
  });

  it('will leave the pending ops queue untouched when a lone save fails', async () => {
    mockFetch([{ status: 404, response: { code: 103, error: 'Invalid class name' } }]);
    const p = new ParseObject('Per$on');
    expect(p._getPendingOps().length).toBe(1);
    p.increment('updates');
    const result = p.save().then(null, err => {
      expect(err.code).toBe(103);
      expect(err.message).toBe('Invalid class name');
      expect(p._getPendingOps().length).toBe(1);
      expect(p.dirtyKeys()).toEqual(['updates']);
      expect(p.get('updates')).toBe(1);
    });
    await result;
  });

  it('will merge pending Ops when a save fails and others are pending', async () => {
    mockFetch([
      { status: 404, response: { code: 103, error: 'Invalid class name' } },
      { status: 404, response: { code: 103, error: 'Invalid class name' } },
    ]);
    const p = new ParseObject('Per$on');
    expect(p._getPendingOps().length).toBe(1);
    p.increment('updates');
    p.save().catch(() => { });
    jest.runAllTicks();
    await flushPromises();
    expect(p._getPendingOps().length).toBe(1);
    p.set('updates', 12);
    p.save().catch(() => { });
    jest.runAllTicks();
    await flushPromises();
    expect(p._getPendingOps().length).toBe(1);
    jest.runAllTicks();
    await flushPromises();
    expect(p._getPendingOps().length).toBe(1);
    expect(p._getPendingOps()[0]).toEqual({
      updates: new ParseOp.SetOp(12),
    });
  });

  it('will deep-save the children of an object', async () => {
    expect.assertions(4);
    mockFetch([
      { status: 200, response: [{ success: { objectId: 'child' } }] },
      { status: 200, response: { objectId: 'parent' } },
    ])
    const parent = new ParseObject('Item');
    const child = new ParseObject('Item');
    child.set('value', 5);
    parent.set('child', child);
    const result = parent.save().then(() => {
      expect(child.id).toBe('child');
      expect(child.dirty()).toBe(false);
      expect(parent.id).toBe('parent');
    });
    await result;
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
  });

  it('will fail for a circular dependency of non-existing objects', async () => {
    const parent = new ParseObject('Item');
    const child = new ParseObject('Item');
    parent.set('child', child);
    child.set('parent', parent);
    await expect(parent.save()).rejects.toThrowError(
      'Cannot create a pointer to an unsaved Object.'
    );
  });

  it('will fail for deeper unsaved objects', async () => {
    const parent = new ParseObject('Item');
    const child = new ParseObject('Item');
    const grandchild = new ParseObject('Item');
    parent.set('child', child);
    child.set('child', grandchild);
    await expect(parent.save()).rejects.toThrowError(
      'Cannot create a pointer to an unsaved Object.'
    );
  });

  it('does not mark shallow objects as dirty', () => {
    const post = new ParseObject('Post');
    post.id = '141414';
    expect(post.dirty()).toBe(false);

    const comment = new ParseObject('Comment');
    comment.set('parent', post);
    expect(unsavedChildren(comment)).toEqual([]);
  });

  it('can fetch an object given an id', async () => {
    expect.assertions(2);
    mockFetch([{ status: 200, response: { count: 10 } }]);
    const p = new ParseObject('Person');
    p.id = 'P55';
    await p.fetch().then(res => {
      expect(p).toBe(res);
      expect(p.attributes).toEqual({ count: 10 });
    });
  });

  it('throw for fetch with empty string as ID', async () => {
    expect.assertions(1);
    mockFetch([{ status: 200, response: { count: 10 } }]);
    const p = new ParseObject('Person');
    p.id = '';
    await expect(p.fetch()).rejects.toThrowError(
      new ParseError(ParseError.MISSING_OBJECT_ID, 'Object does not have an ID')
    );
  });

  it('should fail saveAll batch cycle', async () => {
    const obj = new ParseObject('Item');
    obj.set('child', obj);

    await expect(ParseObject.saveAll([obj])).rejects.toEqual(
      expect.objectContaining({
        message: 'Tried to save a batch with a cycle.',
      })
    );
  });

  it('should fail save with transaction and batchSize option', async () => {
    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');

    await expect(
      ParseObject.saveAll([obj1, obj2], { transaction: true, batchSize: 20 })
    ).rejects.toEqual(
      expect.objectContaining({
        message: 'You cannot use both transaction and batchSize options simultaneously.',
      })
    );
  });

  it('should fail destroy with transaction and batchSize option', async () => {
    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');

    await expect(
      ParseObject.destroyAll([obj1, obj2], { transaction: true, batchSize: 20 })
    ).rejects.toEqual(
      expect.objectContaining({
        message: 'You cannot use both transaction and batchSize options simultaneously.',
      })
    );
  });

  it('should fail save batch with unserializable attribute and transaction option', async () => {
    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj1.set('relatedObject', obj2);

    await expect(ParseObject.saveAll([obj1, obj2], { transaction: true })).rejects.toEqual(
      expect.objectContaining({
        message:
          'Tried to save a transactional batch containing an object with unserializable attributes.',
      })
    );
  });

  it('should fail to save object when its children lack IDs using transaction option', async () => {
    mockFetch([{ status: 200, response: [] }]);

    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj1.set('relatedObject', obj2);

    await expect(obj1.save(null, { transaction: true })).rejects.toEqual(
      expect.objectContaining({
        message:
          'Tried to save a transactional batch containing an object with unserializable attributes.',
      })
    );
  });

  it('should save batch with serializable attribute and transaction option', async () => {
    mockFetch([{
      status: 200,
      response: [{ success: { objectId: 'parent' } }, { success: { objectId: 'id2' } }],
    }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'request');

    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj2.id = 'id2';
    obj1.set('relatedObject', obj2);

    const [saved1, saved2] = await ParseObject.saveAll([obj1, obj2], { transaction: true });

    expect(saved1.dirty()).toBe(false);
    expect(saved2.dirty()).toBe(false);
    expect(saved1.id).toBe('parent');
    expect(saved2.id).toBe('id2');

    expect(controller.request).toHaveBeenCalledWith(
      'POST',
      'batch',
      {
        requests: [
          {
            method: 'POST',
            body: {
              relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' },
            },
            path: '/1/classes/TestObject',
          },
          { method: 'PUT', body: {}, path: '/1/classes/TestObject/id2' },
        ],
        transaction: true,
      },
      expect.anything()
    );
  });

  it('should save object along with its children using transaction option', async () => {
    mockFetch([{
      status: 200,
      response: [{ success: { objectId: 'id2' } }, { success: { objectId: 'parent' } }],
    }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'request');

    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj2.id = 'id2';
    obj2.set('attribute', true);

    obj1.set('relatedObject', obj2);

    const saved1 = await obj1.save(null, { transaction: true });

    const saved2 = saved1.get('relatedObject');
    expect(saved1.dirty()).toBe(false);
    expect(saved2.dirty()).toBe(false);
    expect(saved1.id).toBe('parent');
    expect(saved2.id).toBe('id2');

    expect(controller.request).toHaveBeenCalledWith(
      'POST',
      'batch',
      {
        requests: [
          {
            method: 'PUT',
            body: { attribute: true },
            path: '/1/classes/TestObject/id2',
          },
          {
            method: 'POST',
            body: {
              relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' },
            },
            path: '/1/classes/TestObject',
          },
        ],
        transaction: true,
      },
      expect.anything()
    );
  });

  it('should save file & object along with its children using transaction option', async () => {
    mockFetch([
      {
        status: 200,
        response: { name: 'mock-name', url: 'mock-url' },
      },
      {
        status: 200,
        response: [{ success: { objectId: 'id2' } }, { success: { objectId: 'parent' } }],
      },
    ]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'request');

    const file1 = new ParseFile('parse-server-logo', [0, 1, 2, 3]);
    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj2.id = 'id2';
    obj2.set('file', file1);

    obj1.set('relatedObject', obj2);

    const saved1 = await obj1.save(null, { transaction: true });

    const saved2 = saved1.get('relatedObject');
    expect(saved1.dirty()).toBe(false);
    expect(saved2.dirty()).toBe(false);
    expect(saved1.id).toBe('parent');
    expect(saved2.id).toBe('id2');

    const file = saved2.get('file');
    expect(file.name()).toBe('mock-name');
    expect(file.url()).toBe('mock-url');

    expect(controller.request).toHaveBeenCalledWith(
      'POST',
      'batch',
      {
        requests: [
          {
            method: 'PUT',
            body: { file: { __type: 'File', name: 'mock-name', url: 'mock-url' } },
            path: '/1/classes/TestObject/id2',
          },
          {
            method: 'POST',
            body: {
              relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' },
            },
            path: '/1/classes/TestObject',
          },
        ],
        transaction: true,
      },
      expect.anything()
    );
  });

  it('should destroy batch with transaction option', async () => {
    mockFetch([
      {
        status: 200,
        response: [{ success: { objectId: 'parent' } }, { success: { objectId: 'id2' } }],
      },
    ]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'request');

    const obj1 = new ParseObject('TestObject');
    const obj2 = new ParseObject('TestObject');
    obj1.id = 'parent';
    obj2.id = 'id2';

    await ParseObject.destroyAll([obj1, obj2], { transaction: true });

    expect(controller.request).toHaveBeenCalledWith(
      'POST',
      'batch',
      {
        requests: [
          { method: 'DELETE', body: {}, path: '/1/classes/TestObject/parent' },
          { method: 'DELETE', body: {}, path: '/1/classes/TestObject/id2' },
        ],
        transaction: true,
      },
      expect.anything()
    );
  });

  it('should fail on invalid date', done => {
    const obj = new ParseObject('Item');
    obj.set('when', new Date(Date.parse(null)));
    ParseObject.saveAll([obj])
      .then(() => {
        done.fail('Expected invalid date to fail');
      })
      .catch(error => {
        expect(error[0].code).toEqual(ParseError.INCORRECT_TYPE);
        expect(error[0].message).toEqual('Tried to encode an invalid date.');
        done();
      });
    jest.runAllTicks();
  });

  it('can save a ring of objects, given one exists', async () => {
    mockFetch([
      { status: 200, response: [{ success: { objectId: 'parent' } }] },
      { status: 200, response: [{ success: {} }] },
    ]);
    const parent = new ParseObject('Item');
    const child = new ParseObject('Item');
    child.id = 'child';
    parent.set('child', child);
    child.set('parent', parent);

    const result = ParseObject.saveAll([parent, child]).then(() => {
      expect(child.dirty()).toBe(false);
      expect(parent.id).toBe('parent');
    });
    jest.runAllTicks();
    await flushPromises();

    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests).toEqual([
      {
        method: 'POST',
        path: '/1/classes/Item',
        body: {
          child: {
            __type: 'Pointer',
            className: 'Item',
            objectId: 'child',
          },
        },
      },
    ]);
    jest.runAllTicks();
    await flushPromises();

    expect(parent.id).toBe('parent');
    jest.runAllTicks();

    await result;
  });

  it('accepts context on saveAll', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    // Spy on REST controller
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');
    // Save object
    const context = { a: 'saveAll' };
    const obj = new ParseObject('Item');
    obj.id = 'pid';
    obj.set('test', 'value');
    await ParseObject.saveAll([obj], { context, useMasterKey: true });
    // Validate
    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._context).toEqual(context);
  });

  it('accepts context on destroyAll', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    // Spy on REST controller
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');
    // Save object
    const context = { a: 'b' };
    const obj = new ParseObject('Item');
    obj.id = 'pid';
    await ParseObject.destroyAll([obj], { context: context });
    // Validate
    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._context).toEqual(context);
  });

  it('destroyAll with options', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    const obj = new ParseObject('Item');
    obj.id = 'pid';
    await ParseObject.destroyAll([obj], {
      useMasterKey: true,
      sessionToken: 'r:1234',
      batchSize: 25,
    });

    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._MasterKey).toBe('C');
    expect(jsonBody._SessionToken).toBe('r:1234');
  });

  it('destroyAll with empty values', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    let results = await ParseObject.destroyAll([]);
    expect(results).toEqual([]);

    results = await ParseObject.destroyAll(null);
    expect(results).toEqual(null);
    expect(controller.ajax).toHaveBeenCalledTimes(0);
  });

  it('destroyAll unsaved objects', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    const obj = new ParseObject('Item');
    const results = await ParseObject.destroyAll([obj]);
    expect(results).toEqual([obj]);
    expect(controller.ajax).toHaveBeenCalledTimes(0);
  });

  it('destroyAll handle error response', async () => {
    mockFetch([
      {
        status: 200,
        response: [
          {
            error: {
              code: 101,
              error: 'Object not found',
            },
          },
        ],
      },
    ]);
    const obj = new ParseObject('Item');
    obj.id = 'toDelete1';
    try {
      await ParseObject.destroyAll([obj]);
      expect(true).toBe(false);
    } catch (e) {
      expect(e.code).toBe(600);
    }
  });

  it('can save a chain of unsaved objects', async () => {
    mockFetch([
      { status: 200, response: [{ success: { objectId: 'grandchild' } }] },
      { status: 200, response: [{ success: { objectId: 'child' } }] },
      { status: 200, response: [{ success: { objectId: 'parent' } }] },
    ]);
    const parent = new ParseObject('Item');
    const child = new ParseObject('Item');
    const grandchild = new ParseObject('Item');
    parent.set('child', child);
    child.set('child', grandchild);

    const result = ParseObject.saveAll([parent]).then(() => {
      expect(child.dirty()).toBe(false);
      expect(grandchild.dirty()).toBe(false);
      expect(parent.id).toBe('parent');
      expect(child.id).toBe('child');
      expect(grandchild.id).toBe('grandchild');
    });
    jest.runAllTicks();
    await flushPromises();

    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests).toEqual([
      {
        method: 'POST',
        path: '/1/classes/Item',
        body: {},
      },
    ]);
    expect(fetch.mock.calls[1][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[1][1].body).requests).toEqual([
      {
        method: 'POST',
        path: '/1/classes/Item',
        body: {
          child: {
            __type: 'Pointer',
            className: 'Item',
            objectId: 'grandchild',
          },
        },
      },
    ]);
    expect(fetch.mock.calls[2][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[2][1].body).requests).toEqual([
      {
        method: 'POST',
        path: '/1/classes/Item',
        body: {
          child: {
            __type: 'Pointer',
            className: 'Item',
            objectId: 'child',
          },
        },
      },
    ]);
    jest.runAllTicks();
    await result;
  });

  it('can update fields via a fetch() call', done => {
    mockFetch([
      {
        status: 200,
        response: {
          count: 11,
        },
      },
      {
        status: 200,
        response: {
          count: 20,
        },
      },
    ]);
    const p = new ParseObject('Person');
    p.id = 'P55';
    p.increment('count');
    p.save()
      .then(() => {
        expect(p.get('count')).toBe(11);
        return p.fetch();
      })
      .then(() => {
        expect(p.get('count')).toBe(20);
        expect(p.dirty()).toBe(false);
        done();
      });
  });

  it('replaces old data when fetch() is called', done => {
    mockFetch([
      {
        status: 200,
        response: {
          count: 10,
        },
      },
    ])
    const p = ParseObject.fromJSON({
      className: 'Person',
      objectId: 'P200',
      name: 'Fred',
      count: 0,
    });
    expect(p.get('name')).toBe('Fred');
    expect(p.get('count')).toBe(0);
    p.fetch().then(() => {
      expect(p.get('count')).toBe(10);
      expect(p.get('name')).toBe(undefined);
      expect(p.has('name')).toBe(false);
      done();
    });
  });

  it('can destroy an object', async () => {
    mockFetch([{ status: 200, response: { objectId: 'pid' } }]);
    const p = new ParseObject('Person');
    p.id = 'pid';
    await p.destroy({ sessionToken: 't_1234' });
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/classes/Person/pid');
    expect(JSON.parse(fetch.mock.calls[0][1].body)._method).toBe('DELETE');
    expect(JSON.parse(fetch.mock.calls[0][1].body)._SessionToken).toBe('t_1234');
  });

  it('accepts context on destroy', async () => {
    mockFetch([{ status: 200, response: [{}] }]);
    // Spy on REST controller
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');
    // Save object
    const context = { a: 'a' };
    const obj = new ParseObject('Item');
    obj.id = 'pid';
    await obj.destroy({ context });
    // Validate
    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._context).toEqual(context);
  });

  it('handle destroy on new object', async () => {
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');

    const obj = new ParseObject('Item');

    await obj.destroy({ useMasterKey: true });

    expect(controller.ajax).toHaveBeenCalledTimes(0);
  });

  it('can save an array of objects', async () => {
    mockFetch([{
      status: 200,
      response: [
        { success: { objectId: 'pid0' } },
        { success: { objectId: 'pid1' } },
        { success: { objectId: 'pid2' } },
        { success: { objectId: 'pid3' } },
        { success: { objectId: 'pid4' } },
      ],
    }]);
    const objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
    }
    const results = await ParseObject.saveAll(objects);
    expect(results.every(obj => obj.id !== undefined)).toBe(true);
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests[0]).toEqual({
      method: 'POST',
      path: '/1/classes/Person',
      body: {},
    });
  });

  it('can saveAll with batchSize', async () => {
    const objects = [];
    const response = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
      response[i] = { success: { objectId: `pid${i}` } };
    }
    mockFetch([
      { status: 200, response: response.slice(0, 20) },
      { status: 200, response: response.slice(20) },
    ]);
    await ParseObject.saveAll(objects, { batchSize: 20 });
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(fetch.mock.calls[1][0]).toEqual('https://api.parse.com/1/batch');
  });

  it('can saveAll with global batchSize', async () => {
    const objects = [];
    const response = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
      response[i] = { success: { objectId: `pid${i}` } };
    }
    mockFetch([
      { status: 200, response: response.slice(0, 20) },
      { status: 200, response: response.slice(20) },
    ]);
    await ParseObject.saveAll(objects);
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(fetch.mock.calls[1][0]).toEqual('https://api.parse.com/1/batch');
  });

  it('returns the first error when saving an array of objects', async () => {
    expect.assertions(4);
    const response = [
      { success: { objectId: 'pid0' } },
      { success: { objectId: 'pid1' } },
      { success: { objectId: 'pid2' } },
      { success: { objectId: 'pid3' } },
      { success: { objectId: 'pid4' } },
      { success: { objectId: 'pid5' } },
      { error: { code: -1, error: 'first error' } },
      { success: { objectId: 'pid7' } },
      { success: { objectId: 'pid8' } },
      { success: { objectId: 'pid9' } },
      { success: { objectId: 'pid10' } },
      { success: { objectId: 'pid11' } },
      { success: { objectId: 'pid12' } },
      { success: { objectId: 'pid13' } },
      { success: { objectId: 'pid14' } },
      { error: { code: -1, error: 'second error' } },
      { success: { objectId: 'pid16' } },
      { success: { objectId: 'pid17' } },
      { success: { objectId: 'pid18' } },
      { success: { objectId: 'pid19' } },
    ];
    mockFetch([{ status: 200, response }, { status: 200, response }]);
    const objects = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
    }
    try {
      await ParseObject.saveAll(objects);
    } catch (error) {
      // The second batch never ran
      expect(objects[19].dirty()).toBe(false);
      expect(objects[20].dirty()).toBe(true);
      expect(error.message).toBe('first error');
      expect(fetch.mock.calls.length).toBe(1);
    }
  });
});

describe('ObjectController', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('can fetch a single object', async () => {
    const objectController = CoreManager.getObjectController();
    mockFetch([{ status: 200, response: { objectId: 'pid' } }]);

    const o = new ParseObject('Person');
    o.id = 'pid';
    await objectController.fetch(o);
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/classes/Person/pid');
    const body = JSON.parse(fetch.mock.calls[0][1].body);
    expect(body._method).toBe('GET');
  });

  it('accepts context on fetch', async () => {
    mockFetch([{ status: 200, response: {} }]);
    // Spy on REST controller
    const controller = CoreManager.getRESTController();
    jest.spyOn(controller, 'ajax');
    // Save object
    const context = { a: 'fetch' };
    const obj = new ParseObject('Item');
    obj.id = 'pid';
    await obj.fetch({ context });
    // Validate
    const jsonBody = JSON.parse(controller.ajax.mock.calls[0][2]);
    expect(jsonBody._context).toEqual(context);
  });

  it('can fetch an array of objects', done => {
    const objectController = CoreManager.getObjectController();
    const objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
    }
    objectController.fetch(objects).then(results => {
      expect(results.length).toBe(5);
      expect(results[0] instanceof ParseObject).toBe(true);
      expect(results[0].id).toBe('pid0');
      expect(results[0].className).toBe('Person');
      done();
    });
  });

  it('can fetch a single object with include', async () => {
    expect.assertions(2);
    const objectController = CoreManager.getObjectController();
    mockFetch([{ status: 200, response: { objectId: 'pid' } }]);

    const o = new ParseObject('Person');
    o.id = 'pid';
    await objectController.fetch(o, false, { include: ['child'] });
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/classes/Person/pid');
    const body = JSON.parse(fetch.mock.calls[0][1].body);
    expect(body._method).toBe('GET');
  });

  it('can fetch an array of objects with include', async () => {
    const objectController = CoreManager.getObjectController();
    const objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
    }
    const results = await objectController.fetch(objects, false, {
      include: ['child'],
    });
    expect(results.length).toBe(5);
    expect(results[0] instanceof ParseObject).toBe(true);
    expect(results[0].id).toBe('pid0');
    expect(results[0].className).toBe('Person');
  });

  it('can destroy an object', async () => {
    const objectController = CoreManager.getObjectController();
    mockFetch([
      { status: 200, response: { results: [] } },
      { status: 200, response: { results: [] } },
    ]);
    const p = new ParseObject('Person');
    p.id = 'pid';
    await objectController.destroy(p, {});
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/classes/Person/pid');
    expect(JSON.parse(fetch.mock.calls[0][1].body)._method).toBe('DELETE');
    const p2 = new ParseObject('Person');
    p2.id = 'pid2';
    await objectController.destroy(p2, {
      useMasterKey: true,
    });
    expect(fetch.mock.calls[1][0]).toEqual('https://api.parse.com/1/classes/Person/pid2');
    const body = JSON.parse(fetch.mock.calls[1][1].body);
    expect(body._method).toBe('DELETE');
    expect(body._MasterKey).toBe('C');
  });

  it('can destroy an array of objects with batchSize', async () => {
    const objectController = CoreManager.getObjectController();
    let response = [];
    let objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
      response.push({
        success: { objectId: 'pid' + i },
      });
    }
    mockFetch([{ status: 200, response }]);

    await objectController.destroy(objects, { batchSize: 20 });
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests).toEqual([
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid0',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid1',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid2',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid3',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid4',
        body: {},
      },
    ]);

    objects = [];
    response = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
      response.push({
        success: { objectId: 'pid' + i },
      });
    }
    mockFetch([{ status: 200, response }, { status: 200, response: response.slice(20) }]);

    await objectController.destroy(objects, { batchSize: 20 });
    expect(fetch.mock.calls.length).toBe(2);
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests.length).toBe(20);
    expect(JSON.parse(fetch.mock.calls[1][1].body).requests.length).toBe(2);
  });

  it('can destroy an array of objects', async () => {
    const objectController = CoreManager.getObjectController();
    let response = [];
    let objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
      response.push({
        success: { objectId: 'pid' + i },
      });
    }
    mockFetch([{ status: 200, response }]);

    await objectController.destroy(objects, {});
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests).toEqual([
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid0',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid1',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid2',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid3',
        body: {},
      },
      {
        method: 'DELETE',
        path: '/1/classes/Person/pid4',
        body: {},
      },
    ]);

    objects = [];
    response = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].id = 'pid' + i;
      response.push({
        success: { objectId: 'pid' + i },
      });
    }
    mockFetch([{ status: 200, response }, { status: 200, response: response.slice(20) }]);

    await objectController.destroy(objects, {});
    expect(fetch.mock.calls.length).toBe(2);
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests.length).toBe(20);
    expect(JSON.parse(fetch.mock.calls[1][1].body).requests.length).toBe(2);
  });

  it('can destroy the object eventually on network failure', async () => {
    const p = new ParseObject('Person');
    jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve());
    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { });
    jest.spyOn(p, 'destroy').mockImplementationOnce(() => {
      throw new ParseError(
        ParseError.CONNECTION_FAILED,
        'XMLHttpRequest failed: "Unable to connect to the Parse API"'
      );
    });
    await p.destroyEventually();
    expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(1);
    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1);
  });

  it('should not destroy object eventually on error', async () => {
    const p = new ParseObject('Person');
    jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve());
    jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { });
    jest.spyOn(p, 'destroy').mockImplementationOnce(() => {
      throw new ParseError(ParseError.OTHER_CAUSE, 'Unable to delete.');
    });
    await p.destroyEventually();
    expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(0);
    expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0);
  });

  it('can save an object', async () => {
    const objectController = CoreManager.getObjectController();
    mockFetch([{ status: 200, response: { objectId: 'pid', key: 'value' } }]);

    const p = new ParseObject('Person');
    p.id = 'pid';
    p.set('key', 'value');
    await objectController.save(p, {});
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/classes/Person/pid');
    const body = JSON.parse(fetch.mock.calls[0][1].body);
    expect(body.key).toBe('value');
  });

  it('returns an empty promise from an empty save', done => {
    const objectController = CoreManager.getObjectController();
    objectController.save().then(() => {
      done();
    });
    jest.runAllTicks();
  });

  it('can save an array of files', async () => {
    const objectController = CoreManager.getObjectController();
    const names = ['parse.txt', 'parse2.txt', 'parse3.txt'];
    const responses = [];
    for (let i = 0; i < 3; i++) {
      responses.push({
        status: 200,
        response: {
          name: names[i],
          url: 'http://files.parsetfss.com/a/' + names[i],
        },
      });
    }
    mockFetch(responses);
    const files = [
      new ParseFile('parse.txt', { base64: 'ParseA==' }),
      new ParseFile('parse2.txt', { base64: 'ParseA==' }),
      new ParseFile('parse3.txt', { base64: 'ParseA==' }),
    ];
    await objectController.save(files, {});
    // TODO: why they all have same url?
    // expect(files[0].url()).toBe('http://files.parsetfss.com/a/parse.txt');
    // expect(files[1].url()).toBe('http://files.parsetfss.com/a/parse2.txt');
    expect(files[2].url()).toBe('http://files.parsetfss.com/a/parse3.txt');
  });

  it('can save an array of objects', async () => {
    const objectController = CoreManager.getObjectController();
    let response = [];
    const objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].set('index', i);
      response.push({
        success: { objectId: 'pid' + i, index: i },
      });
    }
    mockFetch([{ status: 200, response }]);
    const results = await objectController.save(objects, {});
    expect(results.length).toBe(5);
    expect(results[0].id).toBe('pid0');
    expect(results[0].get('index')).toBe(0);
    expect(results[0].dirty()).toBe(false);

    response = [];
    for (let i = 0; i < 22; i++) {
      objects[i] = new ParseObject('Person');
      objects[i].set('index', i);
      response.push({
        success: { objectId: 'pid' + i, index: i },
      });
    }
    mockFetch([
      { status: 200, response: response.slice(0, 20) },
      { status: 200, response: response.slice(20) },
    ]);
    const saved = await objectController.save(objects, {});

    for (let i = 0; i < saved.length; i += 1) {
      expect(objects[i].dirty()).toBe(false);
      expect(objects[i].id).toBe(`pid${i}`);
      expect(objects[i].get('index')).toBe(i);
    }
    expect(saved.length).toBe(22);
    expect(fetch.mock.calls.length).toBe(2);
  });

  it('does not fail when checking if arrays of pointers are dirty', async () => {
    mockFetch([
      { status: 200, response: [{ success: { objectId: 'i333' } }] },
      { status: 200, response: {} },
    ])
    const brand = ParseObject.fromJSON({
      className: 'Brand',
      objectId: 'b123',
      items: [{ __type: 'Pointer', objectId: 'i222', className: 'Item' }],
    });
    expect(brand._getSaveJSON()).toEqual({});
    const items = brand.get('items');
    items.push(new ParseObject('Item'));
    brand.set('items', items);
    expect(function () {
      brand.save();
    }).not.toThrow();
  });

  it('can create a new instance of an object', () => {
    const o = ParseObject.fromJSON({
      className: 'Clone',
      objectId: 'C12',
    });
    const o2 = o.newInstance();
    expect(o.id).toBe(o2.id);
    expect(o.className).toBe(o2.className);
    o.set({ valid: true });
    expect(o2.get('valid')).toBe(true);

    expect(o).not.toBe(o2);
  });

  it('cannot create a new instance of an object without className', () => {
    expect(() => {
      ParseObject.fromJSON({});
    }).toThrow('Cannot create an object without a className');
  });
});

describe('ParseObject (unique instance mode)', () => {
  beforeEach(() => {
    ParseObject.disableSingleInstance();
  });

  it('can be created with initial attributes', () => {
    const o = new ParseObject({
      className: 'Item',
      value: 12,
    });
    expect(o.className).toBe('Item');
    expect(o.attributes).toEqual({ value: 12 });
  });

  it('can be inflated from server JSON', () => {
    const json = {
      className: 'Item',
      createdAt: '2013-12-14T04:51:19Z',
      objectId: 'I1',
      size: 'medium',
    };
    const o = ParseObject.fromJSON(json);
    expect(o.className).toBe('Item');
    expect(o.id).toBe('I1');
    expect(o.attributes).toEqual({
      size: 'medium',
      createdAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
      updatedAt: new Date(Date.UTC(2013, 11, 14, 4, 51, 19)),
    });
    expect(o.dirty()).toBe(false);
  });

  it('can be rendered to JSON', () => {
    let o = new ParseObject('Item');
    o.set({
      size: 'large',
      inStock: 18,
    });
    expect(o.toJSON()).toEqual({
      size: 'large',
      inStock: 18,
    });
    o = new ParseObject('Item');
    o._finishFetch({
      objectId: 'O2',
      size: 'medium',
      inStock: 12,
    });
    expect(o.id).toBe('O2');
    expect(o.toJSON()).toEqual({
      objectId: 'O2',
      size: 'medium',
      inStock: 12,
    });
  });

  it('can add, update, and remove attributes', () => {
    const o = new ParseObject({
      className: 'Item',
      objectId: 'anObjectId',
      value: 12,
      valid: true,
    });
    o.set({ value: 14 });
    expect(o.get('value')).toBe(14);
    o.unset('valid');
    expect(o.get('valid')).toBe(undefined);
    expect(o.dirtyKeys()).toEqual(['value', 'valid']);
    o.increment('value');
    expect(o.get('value')).toEqual(15);

    o.clear();
    expect(o.get('value')).toBe(undefined);

    const o2 = ParseObject.fromJSON({
      className: 'Item',
      tags: ['#tbt'],
    });

    o2.add('tags', '#nofilter');
    expect(o2.get('tags')).toEqual(['#tbt', '#nofilter']);

    o2.revert();
    o2.addUnique('tags', '#tbt');
    expect(o2.get('tags')).toEqual(['#tbt']);

    o2.revert();
    o2.remove('tags', '#tbt');
    expect(o2.get('tags')).toEqual([]);
  });

  it('can save the object', done => {
    mockFetch([
      {
        status: 200,
        response: {
          objectId: 'P1',
          count: 1,
        },
      },
    ]);
    const p = new ParseObject('Person');
    p.set('age', 38);
    p.increment('count');
    p.save().then(obj => {
      expect(obj).toBe(p);
      expect(obj.get('age')).toBe(38);
      expect(obj.get('count')).toBe(1);
      expect(obj.op('age')).toBe(undefined);
      expect(obj.dirty()).toBe(false);
      done();
    });
  });

  it('can save an array of objects', async () => {
    mockFetch([{
      status: 200,
      response: [
        { success: { objectId: 'pid0' } },
        { success: { objectId: 'pid1' } },
        { success: { objectId: 'pid2' } },
        { success: { objectId: 'pid3' } },
        { success: { objectId: 'pid4' } },
      ],
    }]);
    const objects = [];
    for (let i = 0; i < 5; i++) {
      objects[i] = new ParseObject('Person');
    }
    const results = await ParseObject.saveAll(objects);
    expect(results.every(obj => obj.id !== undefined)).toBe(true);
    expect(fetch.mock.calls[0][0]).toEqual('https://api.parse.com/1/batch');
    expect(JSON.parse(fetch.mock.calls[0][1].body).requests[0]).toEqual({
      method: 'POST',
      path: '/1/classes/Person',
      body: {},
    });
  });

  it('preserves changes when changing the id', () => {
    const o = new ParseObject({
      className: 'Item',
      objectId: 'anObjectId',
      value: 12,
    });
    o.id = 'otherId';
    expect(o.get('value')).toBe(12);
  });

  it('can maintain differences between two instances of an object', () => {
    const o = new ParseObject({
      className: 'Item',
      objectId: 'anObjectId',
      value: 12,
    });
    const o2 = new ParseObject({
      className: 'Item',
      objectId: 'anObjectId',
      value: 12,
    });
    o.set({ value: 100 });
    expect(o.get('value')).toBe(100);
    expect(o2.get('value')).toBe(12);

    o2.set({ name: 'foo' });
    expect(o.has('name')).toBe(false);
    expect(o2.has('name')).toBe(true);
  });

  it('can create a new instance of an object', () => {
    const o = ParseObject.fromJSON({
      className: 'Clone',
      objectId: 'C14',
    });
    let o2 = o.newInstance();
    expect(o.id).toBe(o2.id);
    expect(o.className).toBe(o2.className);
    expect(o).not.toBe(o2);
    o.set({ valid: true });
    expect(o2.get('valid')).toBe(undefined);
    o2 = o.newInstance();
    expect(o2.get('valid')).toBe(true);
  });
});

class MyObject extends ParseObject {
  constructor() {
    super('MyObject');
  }

  doSomething() {
    return 5;
  }

  static readOnlyAttributes() {
    return ['readonly', 'static', 'frozen'];
  }
}

ParseObject.registerSubclass('MyObject', MyObject);

describe('ParseObject Subclasses', () => {
  beforeEach(() => {
    ParseObject.enableSingleInstance();
  });

  it('can be extended with ES6 classes', () => {
    const o = new MyObject();
    expect(o.className).toBe('MyObject');
    expect(MyObject.className).toBe('MyObject');
    o.id = 'anObjectId';
    expect(o.toPointer()).toEqual({
      __type: 'Pointer',
      className: 'MyObject',
      objectId: 'anObjectId',
    });

    expect(o.doSomething()).toBe(5);

    const o2 = MyObject.createWithoutData('otherId');
    expect(o2 instanceof ParseObject).toBe(true);
    expect(o2 instanceof MyObject).toBe(true);
    expect(o2.toPointer()).toEqual({
      __type: 'Pointer',
      className: 'MyObject',
      objectId: 'otherId',
    });
    expect(o2.doSomething()).toBe(5);
  });

  it('respects readonly attributes of subclasses', () => {
    const o = new MyObject();
    o.set('readwrite', true);
    expect(o.set.bind(o, 'readonly')).toThrow('Cannot modify readonly attribute: readonly');
    expect(o.set.bind(o, 'static')).toThrow('Cannot modify readonly attribute: static');
    expect(o.set.bind(o, 'frozen')).toThrow('Cannot modify readonly attribute: frozen');
  });

  it('registerSubclass errors', () => {
    expect(() => {
      ParseObject.registerSubclass(1234);
    }).toThrow('The first argument must be a valid class name.');

    expect(() => {
      ParseObject.registerSubclass('TestObject', undefined);
    }).toThrow('You must supply a subclass constructor.');

    expect(() => {
      ParseObject.registerSubclass('TestObject', {});
    }).toThrow(
      'You must register the subclass constructor. Did you attempt to register an instance of the subclass?'
    );

    expect(() => {
      ParseObject.unregisterSubclass(1234);
    }).toThrow('The first argument must be a valid class name.');
  });

  it('can use on ParseObject subclass for multiple Parse.Object class names', () => {
    class MyParseObjects extends ParseObject { }
    ParseObject.registerSubclass('TestObject', MyParseObjects);
    ParseObject.registerSubclass('TestObject1', MyParseObjects);
    ParseObject.registerSubclass('TestObject2', MyParseObjects);

    const obj = new MyParseObjects('TestObject');
    expect(obj.className).toBe('TestObject');
    const obj1 = new MyParseObjects('TestObject1');
    expect(obj1.className).toBe('TestObject1');
    const obj2 = new MyParseObjects('TestObject2');
    expect(obj2.className).toBe('TestObject2');

    let classMap = ParseObject._getClassMap();
    expect(classMap.TestObject).toEqual(MyParseObjects);
    expect(classMap.TestObject1).toEqual(MyParseObjects);
    expect(classMap.TestObject2).toEqual(MyParseObjects);

    ParseObject.unregisterSubclass('TestObject');
    ParseObject.unregisterSubclass('TestObject1');
    ParseObject.unregisterSubclass('TestObject2');

    classMap = ParseObject._getClassMap();
    expect(classMap.TestObject).toBeUndefined();
    expect(classMap.TestObject1).toBeUndefined();
    expect(classMap.TestObject2).toBeUndefined();
  });

  it('can inflate subclasses from server JSON', () => {
    const json = {
      className: 'MyObject',
      objectId: 'anotherId',
    };
    const o = ParseObject.fromJSON(json);
    expect(o instanceof ParseObject).toBe(true);
    expect(o.className).toBe('MyObject');
    expect(o.id).toBe('anotherId');
    expect(o.doSomething()).toBe(5);
  });

  it('can be cloned', () => {
    const o = new MyObject();
    o.set({
      size: 'large',
      count: 7,
    });
    const o2 = o.clone();
    expect(o2 instanceof MyObject).toBe(true);
    expect(o2.className).toBe('MyObject');
    expect(o2.attributes).toEqual({
      size: 'large',
      count: 7,
    });
    expect(o2.id).toBe(undefined);
    expect(o.equals(o2)).toBe(false);
  });

  it('can be cleared', () => {
    const o = new MyObject();
    o.set({
      size: 'large',
      count: 7,
    });
    jest.spyOn(o, 'set');
    o.clear();
    expect(o.set).toHaveBeenCalledWith(
      {
        count: true,
        size: true,
      },
      {
        unset: true,
      }
    );
  });
});

describe('ParseObject extensions', () => {
  beforeEach(() => {
    ParseObject.enableSingleInstance();
  });

  it('can extend object', () => {
    const startExtend = Date.now();
    for (let i = 0; i < 100000; i++) {
      const Parent = ParseObject.extend('Parent');

      const parent = new Parent();
    }
    expect(Date.now() - startExtend).toBeLessThan(200);

    const startNew = Date.now();
    for (let i = 0; i < 100000; i++) {
      const parent = new ParseObject('Parent');
    }
    expect(Date.now() - startNew).toBeLessThan(200);
  });

  it('can generate ParseObjects with a default className', () => {
    const YourObject = ParseObject.extend('YourObject');
    const yo = new YourObject();
    expect(yo instanceof ParseObject).toBe(true);
    expect(yo instanceof YourObject).toBe(true);
    expect(yo.className).toBe('YourObject');
    yo.set('greeting', 'yo');
    expect(yo.get('greeting')).toBe('yo');
    expect(yo.attributes).toEqual({
      greeting: 'yo',
    });

    const yo2 = YourObject.createWithoutData('otherId');
    expect(yo2 instanceof ParseObject).toBe(true);
    expect(yo2 instanceof YourObject).toBe(true);
    expect(yo2.toPointer()).toEqual({
      __type: 'Pointer',
      className: 'YourObject',
      objectId: 'otherId',
    });
  });

  it('can extend the prototype and statics of ParseObject', () => {
    const ExtendedObject = ParseObject.extend(
      'ExtendedObject',
      {
        getFoo() {
          return 12;
        },
      },
      {
        isFoo(value) {
          return value === 'foo';
        },
      }
    );
    const e = new ExtendedObject();
    expect(e instanceof ParseObject).toBe(true);
    expect(e instanceof ExtendedObject).toBe(true);
    expect(e.getFoo()).toBe(12);
    expect(ExtendedObject.isFoo(12)).toBe(false);
    expect(ExtendedObject.isFoo('foo')).toBe(true);
  });

  it('can extend a previous extension', () => {
    let FeatureObject = ParseObject.extend('FeatureObject', {
      foo() {
        return 'F';
      },
    });
    let f = new FeatureObject();
    expect(f.foo()).toBe('F');
    FeatureObject = ParseObject.extend('FeatureObject', {
      bar() {
        return 'B';
      },
    });
    f = new FeatureObject();
    expect(f.foo() + f.bar()).toBe('FB');
  });

  it('can specify a custom initializer', () => {
    const InitObject = ParseObject.extend('InitObject', {
      initialize: function () {
        this.set('field', 12);
      },
    });

    const i = new InitObject();
    expect(i.get('field')).toBe(12);
  });

  it('can handle className parameters', () => {
    expect(() => {
      ParseObject.extend();
    }).toThrow("Parse.Object.extend's first argument should be the className.");

    let CustomObject = ParseObject.extend('Item');
    expect(CustomObject.className).toBe('Item');

    CustomObject = ParseObject.extend({ className: 'Test' });
    expect(CustomObject.className).toBe('Test');
  });

  it('can extend with user rewrite', () => {
    const CustomObject = ParseObject.extend('User');
    expect(CustomObject.className).toBe('_User');
  });

  it('can extend multiple subclasses', () => {
    const CustomObject = ParseObject.extend('Item');
    expect(() => {
      new CustomObject({ 'invalid#name': 'bar' });
    }).toThrow("Can't create an invalid Parse Object");

    const CustomUserObject = CustomObject.extend('User');
    const CustomRewrite = CustomUserObject.extend();
    expect(CustomRewrite.className).toBe('_User');
  });
});

describe('ParseObject pin', () => {
  beforeEach(() => {
    ParseObject.enableSingleInstance();
    jest.clearAllMocks();
    mockLocalDatastore.isEnabled = true;
  });

  it('can pin to default', async () => {
    const object = new ParseObject('Item');
    await object.pin();
    expect(mockLocalDatastore._handlePinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handlePinAllWithName).toHaveBeenCalledWith(DEFAULT_PIN, [object]);
  });

  it('can unPin to default', async () => {
    const object = new ParseObject('Item');
    await object.unPin();
    expect(mockLocalDatastore._handleUnPinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handleUnPinAllWithName).toHaveBeenCalledWith(DEFAULT_PIN, [object]);
  });

  it('can pin to specific pin', async () => {
    const object = new ParseObject('Item');
    await object.pinWithName('test_pin');
    expect(mockLocalDatastore._handlePinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handlePinAllWithName).toHaveBeenCalledWith('test_pin', [object]);
  });

  it('can unPin to specific', async () => {
    const object = new ParseObject('Item');
    await object.unPinWithName('test_pin');
    expect(mockLocalDatastore._handleUnPinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handleUnPinAllWithName).toHaveBeenCalledWith('test_pin', [object]);
  });

  it('can check if pinned', async () => {
    const object = new ParseObject('Item');
    object.id = '1234';
    mockLocalDatastore.fromPinWithName
      .mockImplementationOnce(() => {
        return [object._toFullJSON()];
      })
      .mockImplementationOnce(() => []);

    let isPinned = await object.isPinned();
    expect(isPinned).toEqual(true);
    isPinned = await object.isPinned();
    expect(isPinned).toEqual(false);
  });

  it('can fetchFromLocalDatastore', async () => {
    const object = new ParseObject('Item');
    object.id = '123';
    mockLocalDatastore.getKeyForObject.mockImplementationOnce(() => 'Item_123');

    mockLocalDatastore._serializeObject.mockImplementationOnce(() => object._toFullJSON());

    await object.fetchFromLocalDatastore();
    expect(mockLocalDatastore._serializeObject).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._serializeObject).toHaveBeenCalledWith('Item_123');
  });

  it('cannot fetchFromLocalDatastore if unsaved', async () => {
    try {
      const object = new ParseObject('Item');
      await object.fetchFromLocalDatastore();
    } catch (e) {
      expect(e.message).toBe('Cannot fetch an unsaved ParseObject');
    }
  });

  it('can pinAll', async () => {
    const obj1 = new ParseObject('Item');
    const obj2 = new ParseObject('Item');
    await ParseObject.pinAll([obj1, obj2]);
    expect(mockLocalDatastore._handlePinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handlePinAllWithName.mock.calls[0]).toEqual([
      DEFAULT_PIN,
      [obj1, obj2],
    ]);
  });

  it('can unPinAll', async () => {
    const obj1 = new ParseObject('Item');
    const obj2 = new ParseObject('Item');
    await ParseObject.unPinAll([obj1, obj2]);
    expect(mockLocalDatastore._handleUnPinAllWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore._handleUnPinAllWithName.mock.calls[0]).toEqual([
      DEFAULT_PIN,
      [obj1, obj2],
    ]);
  });

  it('can unPinAllObjects', async () => {
    await ParseObject.unPinAllObjects();
    expect(mockLocalDatastore.unPinWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore.unPinWithName.mock.calls[0]).toEqual([DEFAULT_PIN]);
  });

  it('can unPinAllObjectsWithName', async () => {
    await ParseObject.unPinAllObjectsWithName('123');
    expect(mockLocalDatastore.unPinWithName).toHaveBeenCalledTimes(1);
    expect(mockLocalDatastore.unPinWithName.mock.calls[0]).toEqual([PIN_PREFIX + '123']);
  });

  it('cannot pin when localDatastore disabled', async () => {
    mockLocalDatastore.isEnabled = false;
    const name = 'test_pin';
    const obj = new ParseObject('Item');
    try {
      await obj.pin();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await obj.unPin();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await obj.isPinned();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await obj.pinWithName();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await obj.unPinWithName();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await obj.fetchFromLocalDatastore();
    } catch (error) {
      expect(error.message).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.pinAll([obj]);
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.unPinAll([obj]);
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.pinAllWithName(name, [obj]);
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.unPinAllWithName(name, [obj]);
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.unPinAllObjects();
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
    try {
      await ParseObject.unPinAllObjectsWithName(name);
    } catch (error) {
      expect(error).toBe('Parse.enableLocalDatastore() must be called first');
    }
  });
  it('gets id for new object when cascadeSave = false and singleInstance = false', done => {
    ParseObject.disableSingleInstance();
    mockFetch([
      {
        status: 200,
        response: {
          objectId: 'P5',
        },
      },
    ])
    const p = new ParseObject('Person');
    p.save(null, { cascadeSave: false }).then(obj => {
      expect(obj).toBe(p);
      expect(obj.id).toBe('P5');
      done();
    });
  });

  it('can allowCustomObjectId', async () => {
    CoreManager.set('ALLOW_CUSTOM_OBJECT_ID', true);
    const o = new ParseObject('Person');
    o.id = '';
    let params = o._getSaveParams();
    expect(params).toEqual({
      method: 'POST',
      body: { objectId: '' },
      path: 'classes/Person',
    });
    await expect(o.save()).rejects.toEqual(
      new ParseError(ParseError.MISSING_OBJECT_ID, 'objectId must not be empty or null')
    );
    await expect(ParseObject.saveAll([o])).rejects.toEqual(
      new ParseError(ParseError.MISSING_OBJECT_ID, 'objectId must not be empty or null')
    );
    o._finishFetch({
      objectId: 'CUSTOM_ID',
      createdAt: { __type: 'Date', iso: new Date().toISOString() },
      updatedAt: { __type: 'Date', iso: new Date().toISOString() },
    });
    params = o._getSaveParams();
    expect(params).toEqual({
      method: 'PUT',
      body: {},
      path: 'classes/Person/CUSTOM_ID',
    });
    CoreManager.set('ALLOW_CUSTOM_OBJECT_ID', false);
  });

  it('can log an object', () => {
    CoreManager.set('NODE_LOGGING', true);
    const o = new ParseObject('Person', { foo: 'bar' });
    const symbol = Symbol.for('nodejs.util.inspect.custom');
    expect(o[symbol]()).toBe(
      `ParseObject: className: Person, id: undefined\nAttributes: {\n  \"foo\": \"bar\"\n}`
    );
    CoreManager.set('NODE_LOGGING', false);
  });

  describe('Prototype Pollution Protection', () => {
    beforeEach(() => {
      // Clear any pollution before each test
      delete Object.prototype.polluted;
      delete Object.prototype.malicious;
      delete Object.prototype.exploit;
    });

    afterEach(() => {
      // Clean up after tests
      delete Object.prototype.polluted;
      delete Object.prototype.malicious;
      delete Object.prototype.exploit;
    });

    it('should not pollute Object.prototype via prototype className in registerSubclass', () => {
      const testObj = {};

      class MaliciousClass extends ParseObject {
        constructor() {
          super('prototype');
        }
      }

      ParseObject.registerSubclass('prototype', MaliciousClass);

      // Verify Object.prototype was not polluted
      expect(testObj.polluted).toBeUndefined();
      expect({}.polluted).toBeUndefined();
    });

    it('should not pollute Object.prototype when parsing JSON with __proto__ className', () => {
      const testObj = {};

      const maliciousJSON = {
        className: '__proto__',
        objectId: 'test123',
        polluted: 'yes',
      };

      ParseObject.fromJSON(maliciousJSON);

      // Verify Object.prototype was not polluted
      expect(testObj.polluted).toBeUndefined();
      expect({}.polluted).toBeUndefined();
    });

    it('should not pollute Object.prototype when parsing JSON with constructor className', () => {
      const testObj = {};

      const maliciousJSON = {
        className: 'constructor',
        objectId: 'test456',
        malicious: 'data',
      };

      ParseObject.fromJSON(maliciousJSON);

      // Verify Object.prototype was not polluted
      expect(testObj.malicious).toBeUndefined();
      expect({}.malicious).toBeUndefined();
    });

    it('should not pollute Object.prototype when parsing JSON with prototype className', () => {
      const testObj = {};

      const maliciousJSON = {
        className: 'prototype',
        objectId: 'test789',
        exploit: 'here',
      };

      ParseObject.fromJSON(maliciousJSON);

      // Verify Object.prototype was not polluted
      expect(testObj.exploit).toBeUndefined();
      expect({}.exploit).toBeUndefined();
    });

    it('should not pollute when creating objects with malicious class names', () => {
      const testObj = {};

      const maliciousClasses = ['__proto__', 'constructor', 'prototype'];

      maliciousClasses.forEach(className => {
        const obj = new ParseObject(className);
        obj.set('polluted', 'yes');
      });

      // Verify Object.prototype was not polluted
      expect(testObj.polluted).toBeUndefined();
      expect({}.polluted).toBeUndefined();
    });

    it('should not pollute when fromJSON is called multiple times with malicious classNames', () => {
      const testObj = {};

      const maliciousObjects = [
        { className: '__proto__', objectId: '1', polluted: 'yes' },
        { className: 'constructor', objectId: '2', malicious: 'data' },
        { className: 'prototype', objectId: '3', exploit: 'here' },
      ];

      maliciousObjects.forEach(json => ParseObject.fromJSON(json));

      // Verify Object.prototype was not polluted
      expect(testObj.polluted).toBeUndefined();
      expect(testObj.malicious).toBeUndefined();
      expect(testObj.exploit).toBeUndefined();
      expect({}.polluted).toBeUndefined();
      expect({}.malicious).toBeUndefined();
      expect({}.exploit).toBeUndefined();
    });
  });
});
